Published on

JWT와 JWT 사용시 고려할 웹 취약점

Authors
JWT

Spring Security를 처음 붙였을 때 GET은 응답하는데 POST는 403으로 거절돼 당황한 적이 있다. CSRF 보호가 기본으로 켜져 있다는 사실을 알게 된 계기였다. JWT를 쓰면 이 보호가 필요 없는지 이해하려면, 토큰의 형식과 브라우저가 자격 증명을 보내는 방법을 구분해야 한다.

인증과 인가

**인증(Authentication)**은 요청한 주체가 누구인지 확인하는 과정이다. 비밀번호·인증서·토큰 등이 그 근거가 된다. **인가(Authorization)**는 확인한 주체가 해당 자원에서 어떤 작업을 할 수 있는지 판단하는 과정이다. 로그인에 성공했다고 다른 사용자의 데이터를 읽을 권한까지 생기지는 않는다.

JWT는 두 주체 사이에 claim을 전달하는 형식이다. 인증 결과나 권한 정보를 담는 데 사용할 수 있지만, JWT라는 형식 자체가 인증·인가 정책을 대신하지는 않는다.

세션과 JWT는 무엇을 저장하는가

세션 방식에서는 서버가 로그인 상태를 저장하고 브라우저에는 그 상태를 찾는 식별자를 전달한다. 식별자는 보통 쿠키로 보내며, 서버가 이를 조회해 사용자를 식별한다. 여러 서버가 같은 세션을 처리하려면 공유 저장소나 세션 라우팅 정책이 필요하다. 저장소의 성능·장애·만료 정책은 구성에 따라 달라진다.

서명된 JWT는 토큰 안에 claim을 담고 수신자가 검증한다. 매 요청마다 세션 저장소를 조회하지 않는 구조를 만들 수 있지만, 권한 변경·로그아웃·강제 만료 요구가 있으면 별도 상태가 필요할 수 있다. JWT를 쓴다는 이유만으로 서비스 전체가 무상태가 되는 것은 아니다.

서명된 JWT 읽기

여기서는 암호화되지 않은 JWS Compact 형식을 예로 든다. 점으로 구분된 세 부분은 다음과 같다.

base64url(header).base64url(payload).base64url(signature)

헤더와 페이로드는 Base64url로 인코딩된다. 인코딩은 암호화가 아니므로 토큰을 가진 사람이 내용을 읽을 수 있다. 비밀번호나 공개하면 안 되는 비밀 값을 넣어서는 안 된다. 암호화된 JWT인 JWE는 별도 형식이며, 모든 JWT가 위 세 부분이라는 뜻은 아니다.

다음은 헤더 예시다.

{
  "alg": "HS256",
  "typ": "JWT"
}

alg는 서명 또는 MAC 알고리즘을 나타낸다. typ는 토큰 종류를 알리는 선택적 헤더다. 사용하는 프로토콜이 특정 값을 요구할 수 있지만, JWT 일반 규격에서 항상 존재해야 하는 값은 아니다. RFC 7519 §5

페이로드에는 claim을 담는다.

{
  "sub": "user-123",
  "iss": "https://auth.example.com",
  "aud": "example-api",
  "iat": 1793574000,
  "exp": 1793574900
}

sub는 주체, iss는 발급자, aud는 수신 대상, iat와 exp는 발급·만료 시각이다. 예제의 숫자는 설명용이다. 필드 존재 여부와 허용값은 서비스의 토큰 계약에 맞춰 검증한다.

HS256은 공유 비밀키를 사용하는 HMAC이다. 인코딩한 헤더와 페이로드를 점으로 이은 바이트열에 MAC을 계산한다. 이는 데이터를 숨기는 암호화와 다르다. 비대칭 서명을 사용하면 발급자는 개인키로 서명하고 수신자는 공개키로 검증한다.

수신자는 토큰이 제안한 알고리즘을 무조건 신뢰하지 않고 허용 알고리즘·키를 고정한 검증 정책을 사용해야 한다. 서명뿐 아니라 발급자·수신 대상·만료 등도 검증한다. 서명이 맞아도 다른 API를 위해 발급된 토큰을 받아서는 안 된다. 검증은 검증된 라이브러리로 수행한다. RFC 8725 §3

만료와 로그아웃

서명·만료만 검사하는 JWT를 서버가 즉시 무효화하려면 추가 장치가 필요하다. 불가능한 것은 아니다. 토큰 식별자에 대한 거부 목록이나 사용자별 토큰 버전을 조회하는 방법이 있지만, 이 경우 서버 상태 조회가 추가된다.

짧은 access token과 별도의 refresh token을 두는 방법도 있다. refresh token의 저장·회전·폐기 정책으로 재발급을 통제한다. refresh token을 폐기해도 이미 발급된 access token은 만료 또는 별도 취소 검증까지 유효할 수 있다. 토큰 수명을 몇 분·몇 주로 할지는 서비스의 위험과 재인증 요구에 따라 정한다.

CSRF는 토큰 형식보다 전송 방식과 관련된다

브라우저는 쿠키의 Domain·Path·Secure·SameSite 등의 조건에 맞는 요청에 쿠키를 자동으로 붙인다. 공격자가 쿠키 값을 몰라도 피해자의 브라우저를 통해 인증된 요청을 보내게 할 수 있는 이유다.

JWT를 인증 쿠키에 넣었다면 세션 식별자 쿠키와 마찬가지로 CSRF를 검토해야 한다. 반면 앱이 메모리의 토큰을 읽어 Authorization: Bearer ...에 명시적으로 넣는 방식은, 공격자가 임의 사이트에서 동일 헤더를 자동으로 붙일 수 있는 방식과 다르다. 다른 쿠키 인증 경로·CORS·XSS가 없는지 함께 봐야 한다.

전달 방식브라우저의 자동 전송확인할 방어
세션/JWT 인증 쿠키쿠키 조건에 따라 자동 전송CSRF 토큰·Origin 검증·SameSite
앱이 붙이는 Authorization 헤더앱 코드가 명시적으로 추가토큰 유출·XSS·CORS·다른 인증 경로

CSRF 토큰

서버가 만든 난수를 폼의 hidden 필드나 별도 요청 헤더로 제출하고 기대값과 비교한다. 공격자가 브라우저의 자동 쿠키 전송만으로는 유효한 값을 제출하지 못하도록 하는 것이다. 상태를 바꾸는 작업을 GET에 두지 않는 것도 전제다.

CSRF 토큰을 매 요청마다 반드시 폐기해야 하는 것은 아니다. 세션 단위 또는 요청 단위 정책을 선택할 수 있다. 쿠키에 보관한 값을 별도 헤더로 제출·검증하는 패턴도 있으므로, “쿠키를 사용하면 CSRF 방어가 될 수 없다”고 일반화해서는 안 된다. 쿠키만 자동 전송받고 그 값만 확인하는 것은 별도 증명이 되지 않는다. Spring Security CSRF 저장·제출 방식

SameSite와 요청 출처 검증

  • Strict: same-site 요청으로 전송을 제한한다.
  • Lax: same-site 요청과 일부 최상위 안전한 메서드 탐색에 전송한다. 임의의 cross-site POST까지 허용하는 것은 아니다.
  • None: SameSite에 의한 cross-site 제한을 풀며 Secure가 필요하다. 다른 쿠키 범위와 브라우저 정책은 계속 적용된다.

same-site와 same-origin은 다르다. 같은 scheme과 등록 도메인을 공유하는 서브도메인도 서로 다른 origin일 수 있다. SameSite 하나만으로 모든 신뢰 경계가 해결되지는 않는다. 서버의 Origin 검증과 CSRF 토큰을 함께 설계하고, Origin이 없는 요청에 Referer를 사용할 때도 허용 출처와 누락 처리 정책을 명시한다. 쿠키 명세 작업 초안 §5.2

Spring Security의 POST 403

CSRF 토큰 누락·불일치는 POST 403의 원인 중 하나다. 권한 설정 등 다른 원인도 있으므로 로그와 실제 요청을 먼저 확인한다. 폼 또는 SPA의 토큰 전달을 고칠 수 있는데도 오류를 없애려고 CSRF 보호부터 끄면 방어를 잃는다.

Spring Security 6 계열에서 기본 보호를 유지하는 설정 예시는 다음과 같다. CSRF 외 인증·인가 규칙은 서비스에 맞게 별도로 구성해야 한다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(Customizer.withDefaults());
        return http.build();
    }
}

보호를 끌 수 있는지 판단할 때는 “JWT이므로”가 아니라 브라우저가 자동으로 보내는 자격 증명으로 인증되는 경로가 있는지를 확인해야 한다. 예를 들어 Bearer 헤더만 허용하려는 API에 세션 쿠키 인증도 열려 있다면 전제가 달라진다. Spring Security의 CSRF 고려사항

XSS와 저장 위치

XSS는 공격자 코드가 서비스의 출처에서 실행되는 문제다. 그 코드는 localStorage의 토큰을 읽을 수 있고, 사용자의 권한으로 요청을 보낼 수도 있다. localStorage의 동일 출처 정책은 다른 출처의 직접 읽기를 막지만 같은 출처에서 실행된 악성 코드를 막지는 않는다.

HttpOnly 쿠키는 자바스크립트가 쿠키 값을 읽는 것을 막는다. XSS 자체를 막거나 인증된 요청의 실행을 막는 속성은 아니다. 토큰 탈취 경로를 줄이는 방어와 스크립트 실행 자체를 막는 방어를 구분해야 한다.

출력 위치에 맞는 이스케이프를 사용하고, HTML을 허용해야 하는 경우에만 허용 목록 기반 새니타이저를 적용한다. 사용자 입력을 검증했다는 이유로 모든 HTML·URL·스크립트 문맥에서 안전하다고 가정하지 않는다. CSP는 추가 방어 계층으로 두며 정책이 실제 페이지에 적용되는지도 확인한다.

보관 위치장점남는 위험·제약
HttpOnly·Secure 쿠키JS의 토큰 직접 읽기를 제한CSRF 대응 필요, XSS 요청 실행은 별도 방어
localStorage앱에서 헤더로 전달하기 쉬움같은 출처의 XSS가 값을 읽을 수 있음
메모리 변수페이지가 살아 있는 동안만 보관 가능XSS에서 안전한 저장소는 아니며 새로고침 시 복원 정책 필요

저장 위치 하나로 보안이 완성되지는 않는다. 토큰의 수명·재발급·취소, 브라우저의 전송 방식, 서버의 검증과 XSS 방어를 함께 정해야 한다.